Sesión 4 de SM: Introducción al procesamiento de audio con Python¶

Práctica desarrollada por Andres J. Sanchez-Fernandez (sfandres@unex.es) y Juan M. Haut (juanmariohaut@unex.es) para la asignatura Sistemas Multimedia de la Universidad de Extremadura.

Fecha de última modificación: 08/03/2023.

Organización de la clase¶

Turno Tiempo (') Tarea
Alumnos 15 Acceder al GitHub de la práctica y seguir los pasos para replicar mi repositorio
en local y echarlo a andar (hay que seguir el apartado Getting started).
Profesor 45 Explicar el contenido de este notebook.
Alumnos ∞ Realizar el ejercicio de clase que se divide en dos partes: crear un entorno de conda desde cero
(apartado Working on your own from scratch) y analizar ondas de audio con Python3.

Objetivos¶

Los objetivos de esta práctica son los siguientes:

  1. Trabajar con repositorios de GitHub (Git), entornos virtuales de conda (Anaconda), y notebooks con Jupyter lab y Python3.
  2. Aprender a utilizar las librerías más importantes de Python3 para el manejo de audios.
  3. Saber diferenciar entre sonido estéreo y mono, aprendiendo la conversión entre ambos.
  4. Entender lo que es una onda de sonido y cómo se realiza el proceso de adquisición de la misma.
  5. Comprender la importancia de los conceptos básicos que caracterizan una onda:
    1. La frecuencia de muestreo (sample rate).
    2. La profundidad de bits (bit depth).
    3. El ancho de banda (bandwidth).
    4. La tasa de bits (bitrate).
  6. Conocer la teoría de muestreo de Nyquist y el problema de Aliasing.
  7. Conocer la diferencia entre gráfica de onda en el dominio del tiempo vs de la frecuencia (Transformada de Fourier).
  8. Aprender el concepto de energía del espectrograma y realizar la compresión de audio en base al mismo.

Bibliografía consultada¶

Información consultada para el desarrollo de esta práctica:

  • RouteNote Blog: Understanding sample rates in digital audio by Connor Edney [Link]
  • MiniTool: What Is Audio Sample Rate & How to Change Sample Rate of Audio by Cora [Link]
  • Repositorios de Github:
    • Cómo graficar espectrogramas de Audios en Python [Link]
    • Simple audio compression with Python [Link]


Introducción¶

¿Qué es un entorno de Anaconda?¶

missing

Un entorno Conda para Python es un directorio autocontenido que contiene todos los archivos y paquetes necesarios para un proyecto o aplicación Python específica. Te permite instalar y gestionar diferentes versiones de Python y sus dependencias, sin afectar a la instalación global de Python o a otros proyectos en tu sistema.

Conda es un sistema de gestión de paquetes y entornos para cualquier lenguaje de programación. Te permite crear, gestionar y cambiar entre múltiples entornos y compartirlos con otros, lo que facilita la colaboración en proyectos.

Jupyter lab vs Jupyter notebooks¶

missing

JupyterLab y Jupyter Notebook son entornos informáticos interactivos basados en la web que permiten crear y compartir documentos que contienen código, ecuaciones, visualizaciones y texto narrativo.

  • Jupyter Notebook es el más antiguo de los dos y existe desde 2011. Tiene una interfaz más sencilla y proporciona un documento único donde se puede editar y ejecutar código en celdas, y ver la salida de esas celdas en la misma interfaz. También puedes crear celdas de tipo Markdown para documentar y añadir imágenes, vídeos y otros medios a tu cuaderno. Jupyter Notebook es compatible con más de 40 lenguajes de programación, incluidos Python, R, Julia y Scala.
  • JupyterLab, por su parte, se lanzó en 2018 y proporciona una interfaz más potente y versátil para la computación interactiva. Tiene una interfaz con varias pestañas que permite trabajar con varios cuadernos y otros archivos al mismo tiempo. JupyterLab tiene un editor de texto integrado, un terminal y un navegador de archivos, y admite arrastrar y soltar archivos entre pestañas. También dispone de funciones más avanzadas, como completado de código, depuración e integración con sistemas de control de versiones como Git. JupyterLab soporta los mismos lenguajes de programación que Jupyter Notebook.

En resumen, Jupyter Notebook es una herramienta más sencilla y centrada en la computación interactiva, mientras que JupyterLab proporciona una interfaz más versátil y potente para trabajar con múltiples cuadernos y archivos.

Google Colab es un entorno de cuaderno Jupyter. Está construido sobre la infraestructura de Jupyter Notebook y soporta muchas de las mismas características. Además, ofrece varias características únicas, como la posibilidad de utilizar Google Drive para almacenar y acceder a tus cuadernos, así como ejecutar código en la infraestructura en la nube de Google, incluidas las GPUs y TPUs.

Breve introducción a Python3¶

missing

Las principales ventajas de Python son:

  • Fácil de aprender y usar: Python tiene una sintaxis simple y fácil de aprender que lo convierte en un gran lenguaje para principiantes. El código es legible y directo, lo que ayuda a reducir la curva de aprendizaje y facilita la escritura y el mantenimiento del código.
  • Lenguaje interpretado: Python es un lenguaje interpretado, lo que significa que el código se ejecuta línea por línea, haciendo más fácil depurar y probar el código. No es necesario compilar el código antes de ejecutarlo, lo que agiliza el desarrollo y despliegue de aplicaciones.
  • Gran biblioteca estándar: Python viene con una gran biblioteca estándar que proporciona muchos módulos útiles para tareas comunes de programación como el manejo de archivos, redes y acceso a bases de datos. Esto facilita la escritura de programas sin necesidad de instalar bibliotecas de terceros.
  • Bibliotecas de terceros: Python tiene un vasto ecosistema de bibliotecas de terceros que se pueden instalar fácilmente utilizando el gestor de paquetes pip. Estas bibliotecas proporcionan funcionalidad adicional para tareas como la computación científica, el desarrollo web y el aprendizaje automático.
  • Multiplataforma: Python es un lenguaje multiplataforma, lo que significa que el código escrito en un sistema operativo puede ejecutarse en otro sin necesidad de realizar cambios en el código. Esto hace que sea más fácil escribir código que funcione en múltiples plataformas.
  • Orientado a objetos: Python soporta programación orientada a objetos (POO), lo que permite crear código reutilizable que puede organizarse en clases y objetos. Esto facilita la gestión de código complejo y mejora su mantenimiento.

En definitiva, Python3 y Python en general son lenguajes populares por su sencillez, facilidad de uso y amplia gama de aplicaciones. Con su gran biblioteca estándar y bibliotecas de terceros, Python se puede utilizar para una amplia variedad de tareas y es una gran opción tanto para principiantes como para desarrolladores experimentados.

Esto es una celda y es donde se ejecuta código:

In [ ]:
 

En Python no se le indica el tipo de variable que es:

In [1]:
# int num = 5  # Mal.
num = 5
print(num, type(num))

num = 5.
print(num, type(num))

cad = 'Hola mundo!'
print(cad, type(cad))
5 <class 'int'>
5.0 <class 'float'>
Hola mundo! <class 'str'>

En este notebook se utilizan mucho los f-Strings (format string) de Python3:

In [2]:
num = 11
print('Este es mi número favorito:', num)            # Cast automático.
print('Este es mi número favorito: ' + str(num))     # Nosotros hacemos cast.
print('Este es mi número favorito: {}'.format(num))  # Forma más interesante.
print(f'Este es mi número favorito: {num}')  # Aún más interesante y versátil.
Este es mi número favorito: 11
Este es mi número favorito: 11
Este es mi número favorito: 11
Este es mi número favorito: 11


Primeros pasos: sonido estéreo vs mono¶

Importar librerías y módulos de Python¶

Nota: Estas no son las únicas librerías de Python con las que se pueden trabajar para el procesamiento de archivos de audio (otro ejemplo podría ser Librosa). Si alguien está familiarizado con otras librerías puede utilizarlas.

Importamos las librerías/módulos específicos de la siguiente forma.

In [3]:
# Importacion.
# import librosa
from scipy.io import wavfile
import IPython
import os
import numpy as np
import matplotlib.pyplot as plt

Especificar directorios de entrada y salida¶

Aquí definimos los directorios donde guardaremos los audios con los que vamos a trabajar, así como dónde se van a guardar aquellos que generamos a lo largo de la práctica.

In [4]:
# Directorios que usaremos.
cwd = os.getcwd()
audio_input_path = os.path.join(cwd, os.path.join('audio', '_input'))  # cambiar '_input' por 'examples'
audio_output_path = os.path.join(cwd, os.path.join('audio', '_output'))
print(f'Directorio con los audios de entrada: {audio_input_path}')
print(f'Directorio donde guardaremos los audios generados: {audio_output_path}\n')
Directorio con los audios de entrada: /home/sfandres/Documents/Git/uex-audiopy/audio/_input
Directorio donde guardaremos los audios generados: /home/sfandres/Documents/Git/uex-audiopy/audio/_output

Cargar el archivo de audio¶

Diferencias entre formatos de archivo para almacenar audio digital.

  • .wav: Archivo de audio sin comprimir (máxima calidad y gran tamaño de archivo). Típicamente utilizado en edición de audio debido a su fidelidad.
  • .mp3 (por ejemplo): Archivo de audio comprimido (con pérdidas pero menor tamaño). Ampliamente usado.

Cargamos el archivo de audio .wav en este caso.

In [5]:
# Cargamos el archivo de audio.
filename = os.path.join(audio_input_path, 'sample1_stereo.wav')
# audio_data, sample_rate = librosa.load(filename, sr=None, mono=False)
sample_rate, audio_data = wavfile.read(filename)
print(f'Frecuencia de muestreo (sample rate): {sample_rate/1000} kHz')
Frecuencia de muestreo (sample rate): 44.1 kHz

Vamos a escucharlo. Para que esto se haga correctamente, hay que indicarle la frecuencia de muestreo (veremos más adelante qué es).

In [6]:
IPython.display.Audio(audio_data.T, rate=sample_rate) # .T se pasa únicamente si es audio estéreo.
Out[6]:
Your browser does not support the audio element.

Mostrar principales características de la onda¶

Vamos a mostrar la información. Nota: es audio estereo (dos canales).

In [7]:
# Mostrar informacion (sonido estéreo).
print('Datos de audio (estereo):')
print(f'- Tamaño:     {audio_data.shape}')
print(f'- 1º canal:   {audio_data[:5, 0]}...')
print(f'- 2º canal:   {audio_data[:5, 1]}...')
print(f'- Resolucion: {type(audio_data[0,0])}\n')
Datos de audio (estereo):
- Tamaño:     (5384326, 2)
- 1º canal:   [137 140  91   4 -69]...
- 2º canal:   [ 66  38   0 -10  45]...
- Resolucion: <class 'numpy.int16'>

Ahora, por simplificación, vamos a calcular la media por canal para obtener un sonido mono.

In [8]:
# Convertimos a mono mediante la media por canal (simplificacion).
new_data_mono = audio_data.mean(axis=1)  # Column-wise.
print('Nuevos datos de audio (mono):')
print(f'- Nuevo tamaño: {new_data_mono.shape}')
print(f'- Canal unico:  {new_data_mono[:5]}...')

# Mantenemos la misma resolucion que antes.
new_data_mono = new_data_mono.astype(np.int16)
print(f'- Resolucion:   {type(new_data_mono[0])}\n')
Nuevos datos de audio (mono):
- Nuevo tamaño: (5384326,)
- Canal unico:  [101.5  89.   45.5  -3.  -12. ]...
- Resolucion:   <class 'numpy.int16'>

Vamos a guardarlo.

In [9]:
# Guardamos el archivo mono a un fichero de tipo wav.
wavfile.write(
    filename=os.path.join(audio_output_path, 'sample1_mono.wav'),
    rate=sample_rate,
    data=new_data_mono
)

Vamos a escucharlo de nuevo.

In [10]:
IPython.display.Audio(new_data_mono, rate=sample_rate)
Out[10]:
Your browser does not support the audio element.

Se nota que ahora es sonido mono (sobre todo si utilizais cascos).

  • Mono: se escucha lo mismo por el auricular derecho que por el izquierdo.
  • Estéreo: no se escucha el mismo sonido por ambos canales, sino que se notan variaciones entre los dos.

Vamos a ver las diferencias en tamaño de cada archivo.

In [11]:
!ls -sh audio/_input/sample1_stereo.wav
!ls -sh audio/_output/sample1_mono.wav
21M audio/_input/sample1_stereo.wav
11M audio/_output/sample1_mono.wav

Como podemos ver el tamaño se ha reducido a la mitad (manteniendo el la frecuencia de muestreo). Mostramos por pantalla la frecuencia de muestreo (sample rate) del archivo de audio:

In [12]:
print(f'Frecuencia de muestreo (sample rate): {sample_rate/1000} kHz\n')
Frecuencia de muestreo (sample rate): 44.1 kHz

Muy bien la diferencia entre sonido estéreo y mono pero: ¿cómo se adquiere esta onda de audio?, ¿qué significa esta frecuencia de muestreo?, etc.

Todo esto y más lo veremos a continuación.



Teoría: Toma de muestras de audio¶

Definición de muestra¶

Una muestra es una medición instantánea de una onda sonora: una medida de la amplitud de la onda (intensidad de la señal).

Un sistema digital, como una interfaz de audio, toma miles de muestras individuales que registran la amplitud de la onda sonora (cambios en la presión del aire) para reconstruirla digitalmente.

missing
Figura: Ejemplo de una onda sonora simplificada [Ref].

Las ondas sonoras son ondas continuas y están formadas por ciclos de onda individuales. Por tanto, una muestra es una medición de la amplitud de un ciclo de onda individual.

Entonces nuestra interfaz puede convertir la onda sonora en datos binarios, interpretables por nuestro ordenador. Ahora bien, ¿cada cuánto tomamos una muestra?

Frecuencia de muestreo (sample rate)¶

Definición¶

La frecuencia de muestreo es una medida del número de muestras que tomamos por segundo de audio y, por tanto, de la velocidad a la que lo hacemos.

En la práctica, necesitamos tomar miles de muestras de nuestra grabación por segundo si queremos que nuestra señal digital suene fiel a nuestra onda sonora original.

missing
Figura: Explicación visual de la frecuencia de muestreo de una onda de audio. En el primer ejemplo, el resultado digital es deficiente porque las muestras no son lo bastante frecuentes. En el segundo, el resultado es mucho mejor y más suave. Ahora bien, es en el tercer ejemplo donde el resultado digital es tan suave como el audio original al tomarse suficientes muestras [Ref].

A mayor frecuencia de muestreo capturamos mayor cantidad de detalles de la onda original.

Por otro lado, la unidad de la frecuencia de muestreo es el Hercio (Hz), que equivale a un periodo de muestreo T = 1s, aunque normalmente se utiliza la unidad derivada kHz (1 kHz = 1 000 Hz).

Valores más comunes de frecuencia de muestreo¶

Una de las frecuencias de muestreo de audio más común actualmente es de 44,1 kHz, esto significa que se toman 44 100 muestras de audio por segundo. Recapitulando, las más habituales son:

  • 44,1 kHz (44 100 muestras/s) es la frecuencia de muestreo de audio estándar para el audio de CD, y también se utiliza para el audio MPEG-1. Además, hay otras frecuencias de muestreo que se utilizan para otros casos.
  • 8 kHz es la frecuencia de muestreo de audio utilizada por los teléfonos, ya que es suficiente para el habla humana.
  • 32 kHz es ampliamente utilizada por las videocámaras digitales MiniDV, DAT, cintas de vídeo, etc.
  • 48 kHz es la frecuencia de muestreo que suelen utilizar los equipos de vídeo digital, DVD, TV digital, películas y la mayoría de los equipos de audio profesionales.
  • 50 kHz es la frecuencia de muestreo utilizada principalmente por los grabadores digitales comerciales.
  • 88,2 kHz es la frecuencia de muestreo utilizada por algunos equipos de grabación profesionales cuando se apunta a un CD para realizar grabaciones de alta resolución.
  • 96 kHz es el doble del estándar de 48 kHz y se utiliza principalmente en DVD-Audio, pistas de audio de Blu-ray Disc, pistas de audio de DVD de alta definición, etc. Y también está disponible en algunos equipos profesionales de grabación y producción de audio.
  • 192 kHz empleada principalmente en Hi-Res audio junto con la de 96.

Pregunta sencilla: a mayor frecuencia de muestreo, ¿mayor calidad?¶

La solución es también sencilla: ¡Sí!

Al aumentar el número de muestras que se toman por segundo, tenemos una mayor resolución, y con ello una mayor calidad del audio.

Sin embargo, no todo es bueno: Al grabar con una frecuencia de muestreo más alta se crea un archivo de audio también mayor. Es decir, como se toman más muestras, se necesita más espacio en disco para almacenarlas y un procesador con mayor potencia para su procesamiento.

Ya no es solo el mayor espacio ocupado en disco, sino que un archivo de audio grande es contraproducente para transmitirlo por internet, por ejemplo, en plataforma de streaming tales como Spotify, Amazon Music, etc.

Caso real: ¿A qué valor fijamos la frecuencia de muestreo?¶

Posible respuesta: Teorema de muestreo de Nyquist-Shannon.

La teoría de Nyquist establece que necesitamos una frecuencia de muestreo igual al doble de la frecuencia más alta de una señal para capturar todas las frecuencias de la misma.

Este hecho se debe a que un ciclo de onda singular siempre tiene un valor de amplitud negativo y otro positivo. Estos valores son la medida de la intensidad de la señal de los ciclos (amplitud). Y para hallar la longitud de onda de cada ciclo de onda---que determina sus frecuencias---se deben muestrear las amplitudes positiva y negativa de cada ciclo.

En consecuencia, debemos muestrear cada ciclo dos veces (como mínimo) si queremos que nuestra señal digital tenga la frecuencia correcta.

missing
Figura: Onda sonora muestreada según el teorema de Nyquist [Ref].

Cuando el muestreo sale mal: Aliasing¶

La teoría de muestreo de Nyquist nos dice que tomar menos de 2 muestras por ciclo conduce a una reconstrucción digital incorrecta de nuestra señal: un "alias" no deseado del original. Por tanto, se denomina aliasing, o solapamiento, al efecto que causa que señales continuas distintas sean indistinguibles cuando se muestrean digitalmente a causa de una frecuencia de muestreo demasiado baja. Cuando esto sucede, la señal original no puede ser reconstruida de forma unívoca a partir de la señal digital.

Ejemplo: Vamos a muestrear la siguiente señal siguiendo el criterio de Nyquist:

missing
Figura: Onda sonora muestreada con una frecuencia de muestro que cumple con el teorema de Nyquist [Ref].

Podemos observar como la señal reconstruida (3) es similar a la original (1) ya que hemos tomado suficientes muestras (2).

Ahora bien, si aumentamos la frecuencia de la señal original sin aumentar el número de muestras ocurre lo siguiente:

missing
Figura: Onda sonora muestreada con una frecuencia de muestro menor que la necesaria según el teorema de Nyquist [Ref].

Ahora podemos ver que, a pesar de que la nueva señal (4) es muy diferente a la que vimos anteriormente (1), el resultado de la señal reconstruida (6) es el mismo que en el caso anterior (3).

Entonces concluímos que se ha producido aliasing.



Mas teoría: Características de grabación de audio digital¶

Profundidad de bits (bit depth)¶

Una vez que hemos capturado la onda como se ha explicado, hay que almacenar la información en forma de bits.

La profundidad de bits determina cuántos bits disponibles hay para medir la onda sonora en primer lugar, y luego para que almacenemos nuestras muestras en bytes digitales.

missing
Figura: Conversión Analógica Digital (ADC) [Ref].

Ancho de banda (bandwidth)¶

En otras palabras, la profundidad de bits y la frecuencia de muestreo trabajan juntas para darnos el ancho de banda total de nuestra grabación digital.

\begin{equation} Profundidad\;de\;bits\;(bit\;depth)+frecuencia\;de\;muestreo\;(sample\;rate)=ancho\;de\;banda\;(bandwidth) \end{equation}

Esto significa que el ancho de banda total define la precisión de nuestra señal digital con respecto a la grabación original. Más ancho de banda implica una reproducción más exacta.

missing
Figura: La frecuencia de muestreo y la profundidad de bits forman el ancho de banda de audio total. La cantidad de bits disponibles se corresponde con el número de valores de amplitud digital a los que podemos asignar nuestras muestras [Ref].

Suele llevar a confusión: Frecuencia de muestreo vs tasa de bits (bitrate)¶

Como se ha explicado, la frecuencia de muestreo representa cuántas muestras de la onda sonora original tomamos por segundo. Aunque tenemos otra variable también: el flujo o tasa de bits, que determina el tamaño del archivo de audio digital. La tasa de bits es un cálculo matemático del tamaño de los archivos digitales en megabytes por segundo (Mbps)---no confundir con la profundidad de bits.

Para calcular la tasa de bits de tu grabación digital, puedes utilizar el siguiente cálculo:

\begin{equation} bitrate\,(Mbps) = f_s \cdot f_d \cdot n_c \end{equation}

donde $f_s$ es la frecuencia de muestreo, $f_d$ es la profundidad de bits y $n_c$ es el número de canales de la grabación.

En base a todo esto, se puede decir que la tasa de bits determina el número de bits que el ordenador debe procesar por segundo para reproducir la grabación de audio digital de la forma prevista.

Ejemplo: Supongamos que grabamos un riff de guitarra con una frecuencia de muestreo de 44,1 kHz y una profundidad de bits de 24 bits. Entonces tendríamos una tasa de bits de:

$44\,100\,(Hz)\cdot 24\,(bits) \cdot 1\,canal = 1\,058\,400\,(bps) = 1,1\,(Mbps)$

Después de calcular la tasa de bits de tu grabación, multiplícala por el número de segundos de tu grabación si quieres saber el tamaño total del archivo. Digamos que grabamos durante 20 segundos, nos quedaría:

$1,1\,(Mbps) \cdot 20\,(s) = 22\,Mb$



Práctica: Compresión de audio¶

Cargar audios¶

Vamos a cargar otros dos archivos de audio adquiridos a distintas frecuencias de muestreo: 48 y 24 kHz.

In [13]:
# Cargamos los archivos de audio.
sample_rate_48, audio_data_48 = wavfile.read(filename=os.path.join(audio_input_path, 'sample_48kHz.wav'))
sample_rate_24, audio_data_24 = wavfile.read(filename=os.path.join(audio_input_path, 'sample_24kHz.wav'))

# Otro ejemplo sería: audio_data, sample_rate = librosa.load(filename)

print(f'{audio_data_48.shape}\n')  # Audio mono
print(f'{audio_data_24.shape}\n')  # Audio mono
(176542,)

(88271,)

Vamos a escuchar los audios.

In [14]:
IPython.display.Audio(audio_data_48, rate=sample_rate_48)
Out[14]:
Your browser does not support the audio element.
In [15]:
IPython.display.Audio(audio_data_24, rate=sample_rate_24)
Out[15]:
Your browser does not support the audio element.

Tenemos los siguientes tamaños de archivo.

In [16]:
!ls -sh audio/_input/sample_48kHz.wav
!ls -sh audio/_input/sample_24kHz.wav
348K audio/_input/sample_48kHz.wav
176K audio/_input/sample_24kHz.wav

Como se puede ver y es lógico, la frecuencia de muestreo también afecta al tamaño del archivo de audio

Gráficas características¶

Dominio del tiempo¶

In [17]:
ampl_values_48 = len(audio_data_48)
ampl_values_24 = len(audio_data_24)
print(f'Número de muestras del audio con fs=48 kHz (valores de amplitud): {ampl_values_48}')
print(f'Número de muestras del audio con fs=24 kHz (valores de amplitud): {ampl_values_24}')
Número de muestras del audio con fs=48 kHz (valores de amplitud): 176542
Número de muestras del audio con fs=24 kHz (valores de amplitud): 88271
In [18]:
# Construimos el array para el eje x que representa el tiempo de la grabación.
# Tiene la forma: np.arange(Vi, Vf, P). Explicado a continuación.
t1 = np.arange(0, ampl_values_48/sample_rate_48, 1/sample_rate_48)
t2 = np.arange(0, ampl_values_24/sample_rate_24, 1/sample_rate_24)

Vamos a construir el eje x con un array que tenga como:

  • Valor inicial ($V_i$): $$V_i=0\;s$$

  • Valor final ($V_f$): $$V_f = \frac{N}{f_s}$$
    donde $N$ representa el número total de muestras (valores de amplitud) y $f_s$ es la frecuencia de muestreo en Hz (número total de muestras capturadas por segundo). Esto nos da la duración total de la grabación en segundos.

  • Paso o step ($P$): $$P=T=\frac{1}{f_s}$$
    donde $T$ se refiere al periodo de la señal.

In [19]:
# Creamos la figura.
fig, ax = plt.subplots(2, 1, figsize=(12, 6), sharex=True)

# Solo mostramos las primeras 50 muestras de amplitud (por claridad).
end = 50

# Señal a 48 kHz.
ax[0].plot(t1[:end], audio_data_48[:end], marker='X')
ax[0].set_title(f'Audio en el dominio del tiempo muestreado a {sample_rate_48} Hz')
ax[0].set_ylabel('Amplitud')
ax[0].grid(True)

# Señal a 24 kHz.
# Utilizamos ratio para ajustar el eje x de ambas gráficas
# ya que la fs es menor en esta señal.
ratio = sample_rate_48 / sample_rate_24  
ax[1].plot(t2[:int(end/ratio)], audio_data_24[:int(end/ratio)], c='tab:red', marker='X')
ax[1].set_title(f'Audio en el dominio del tiempo muestreado a {sample_rate_24} Hz')
ax[1].set_xlabel('Tiempo (s)')
ax[1].set_ylabel('Amplitud')
ax[1].grid(True)

# Mostramos la figura.
plt.tight_layout()
plt.show()

Cada cruz que se ve en las gráficas representa una muestra recogida del audio. Podemos ver que la primera onda tiene un mayor número de muestras recogidas para el mismo periodo de tiempo (mismos valores en el eje x). Por tanto, la primera onda tiene también una precisión mayor que la segunda al tener el doble de frecuencia de muestreo, siendo entonces más fiel a la onda sonora original (fuente).

Dominio de la frecuencia: Transformada de Fourier (FFT)¶

Teoría¶

Vamos a descomponer la señal en vez de en el dominio del tiempo (eje x igual a tiempo en segundos) en el dominio de la frecuencia (eje x igual a frecuencia en Hz). A continuación se presentan dos ejemplos animados del funcionamiento de la Transformada de Fourier:

missing
Figura animada: Ejemplo 1 de la transformada de Fourier [Ref].
missing
Figura animada: Ejemplo 2 de la transformada de Fourier [Ref].

Análisis de Fourier¶

La "Transformación rápida de Fourier" (FFT para abreviar) descompone una señal en sus componentes espectrales individuales, proporcionando información sobre su composición. Este es un importante método de medición en la tecnología de medición de audio y acústica.

missing
Figura: Transformada de Fourier [Ref].
In [20]:
# La longitud del array de datos y el
# sample rate (frecuencia de muestreo).
n = len(audio_data_48)
Fs = sample_rate_48

# Working with stereo audio, there are two channels in the audio data.
# Let's retrieve each channel seperately:
# ch1 = np.array([data[i][0] for i in range(n)]) #channel 1
# ch2 = np.array([data[i][1] for i in range(n)]) #channel 2
# We can then perform a Fourier analysis on the first
# channel to see what the spectrum looks like.

# Calculando la Transformada Rapida de Fourier (FFT) en audio mono.
ch_Fourier = np.fft.fft(audio_data_48)  # ch1

# Solo miramos frecuencia por debajo de Fs/2
# (Nyquist-Shannon) --> Spectrum.
abs_ch_Fourier = np.absolute(ch_Fourier[:n//2])

# Graficamos.
plt.plot(np.linspace(0, Fs/2, n//2), abs_ch_Fourier)
plt.ylabel('Amplitud', labelpad=10)
plt.xlabel('$f$ (Hz)', labelpad=10)
plt.show()

Energia del espectrograma y frecuencia de corte¶

Ahora vamos a definir una frecuencia umbral $f_0$ por la que cortar el espectro, es decir, solo nos quedaremos con aquellas frecuencias que esten por debajo de este valor para el archivo de audio comprimido.

Con este fin, definimos el parámetro epsilon $\epsilon \in (0, 1)$ que representa la parte de la energía del espectro que NO conservamos (la integral con respecto a la frecuencia).

En esta práctica, a modo de ejemplo, seleccionamos un $\epsilon=10^{-5}$ para quitar únicamente una parte, podéis jugar con este valor como queráis para ver el resultado. De manera intuitiva, a medida que aumenta su valor, se mantienen menos frecuencias por lo que la calidad del audio es peor aunque el tamaño del archivo de salida es más reducido. Por lo que tenemos que buscar un buen balance en este sentido.

In [21]:
# Definimos epsilon: la parte de la energia
# del espectro que no conservamos.
eps = [1e-5, .02, .041, .063, .086, .101, .123]

# Jugamos con los valores de epsilon (CAMBIAD ESTO).
eps = eps[0]
print(f'Epsilon: {eps}')

# Calculamos el valor de corte para esta energia.
thr_spec_energy = (1 - eps) * np.sum(abs_ch_Fourier)
print(f'Valor de corte para la energia del espectro: {thr_spec_energy}')

# Integral de la frecuencia --> energia del espectro.
spec_energy = np.cumsum(abs_ch_Fourier)

# Mascara (array booleano) que compara el valor
# de corte con la energia del espectro.
frequencies_to_remove = thr_spec_energy < spec_energy  
print(f'Mascara: {frequencies_to_remove}')

# La frecuencia f0 por la que cortamos el espectro.
f0 = (len(frequencies_to_remove) - np.sum(frequencies_to_remove)) * (Fs/2) / (n//2)
print(f'Frecuencia de corte f0 (Hz): {int(f0)}')

# Graficamos.
plt.axvline(f0, color='r')
plt.plot(np.linspace(0, Fs/2, n//2), abs_ch_Fourier)
plt.ylabel('Amplitud')
plt.xlabel('$f$ (Hz)')
plt.show()
Epsilon: 1e-05
Valor de corte para la energia del espectro: 50667270320.0895
Mascara: [False False False ...  True  True  True]
Frecuencia de corte f0 (Hz): 23992

Compresión del archivo¶

Reducción de la resolución de muestreo (downsampling)¶

Para reducir el tamaño del archivo de audio, lo que vamos a hacer es aplicar downsampling. Vamos a definir el factor de downsampling $D$, el cual utilizaremos para quedarnos únicamente con la secuencia de valores ($\hat{x}$) del audio original ($x$) como sigue:

\begin{equation} \hat{x}[i] = x[D\cdot i] \end{equation}

Esta equación quiere decir que la secuencia de audio resultante estará compuesta por aquellos elementos elementos situados en las posiciones múltiplo de $D$ (slicing). Para saber qué valor debemos utilizar para el downsampling, una opción es la de calcular $D=\frac{f_s}{f_0}$, por lo que la nueva frecuencia de muestreo (sample rate) estaría definida por $\hat{f}_s=\frac{f_s}{D}$.

Gracias a todo este proceso, las frecuencias que constituyen la parte principal del espectro quedarán por debajo de la nueva frecuencia de muestreo.

In [22]:
# Definimos los nombres de los audios comprimidos.
wav_compressed_file = "sample_48kHz_compressed.wav"

# Calculamos el factor D de downsampling.
D = int(Fs / f0)
print(f'Factor de downsampling: {D}')

# Obtenemos los nuevos datos (slicing with stride).
new_data = audio_data_48[::D]

# Escribimos los datos a un archivo de tipo wav.
wavfile.write(
    filename=os.path.join(audio_output_path, wav_compressed_file),
    rate=int(Fs/D),
    data=new_data
)

# Cargamos el nuevo archivo.
new_sample_rate, new_audio_data = wavfile.read(filename=os.path.join(audio_output_path, wav_compressed_file))
Factor de downsampling: 2

Vamos a escucharlo.

In [23]:
IPython.display.Audio(new_audio_data, rate=new_sample_rate)
Out[23]:
Your browser does not support the audio element.

Consejo: probad a cambiar la variable eps y ejecutar de nuevo las celdas para ir viendo como se deteriora la calidad al aumentar el factor de downsampling.

En tamaño tenemos ahora:

In [24]:
!ls -sh audio/_output/sample_48kHz_compressed.wav
176K audio/_output/sample_48kHz_compressed.wav

Espectrograma¶

In [25]:
fig, ax = plt.subplots(2, 1, figsize=(12, 8), sharex=True)

Pxx, freqs, bins, im = ax[0].specgram(audio_data_48, NFFT=1024, Fs=sample_rate_48, noverlap=512)
ax[0].set_title('Espectograma del audio original')
ax[0].set_ylabel('Frecuencia (Hz)')
ax[0].grid(True)

Pxx, freqs, bins, im = ax[1].specgram(new_audio_data, NFFT=1024, Fs=new_sample_rate, noverlap=512)
ax[1].set_title('Espectrograma del audio reducido/comprimido')
ax[1].set_xlabel('Tiempo (s)')
ax[1].set_ylabel('Frecuencia (Hz)')
ax[1].grid(True)

plt.tight_layout()
plt.show()

Podemos observar como la resolución de la amplitud se ha reducido aunque se pueden apreciar aún características similares en ambos espectrogramas.



In [26]:
# # Ignorar: Codigo para reducir un archivo de audio truncando
In [27]:
# # Input data.
# audio_examples_input_path = os.path.join(cwd, os.path.join('audio', 'examples'))
# filename = os.path.join(audio_examples_input_path, 'interstellar.wav')
# sample_rate_v0, audio_data_v0 = wavfile.read(filename)
# print(f'Shape of the input audio data: {audio_data_v0.shape}\n')
In [28]:
# # Slicing with stride (stereo).
# new_data_v0 = audio_data_v0[:10856495//4, :]

# # Writing to a wav file.
# wavfile.write(
#     filename=os.path.join(audio_examples_input_path, 'interstellar2.wav'),
#     rate=sample_rate_v0,
#     data=new_data_v0
# )

# # Loading the new audio data.
# filename = os.path.join(audio_examples_input_path, 'interstellar2.wav')
# sample_rate_v1, audio_data_v1 = wavfile.read(filename)
# print(f'Shape of the input audio data min: {audio_data_v1.shape}')
# print(f'Reduction (%): {int((len(audio_data_v0)-len(audio_data_v1))*100/len(audio_data_v0))}')
In [29]:
# IPython.display.Audio(audio_data_v1.T, rate=sample_rate_v1)
In [ ]: